Repository navigation
Conversation
The lockout paths in DefaultUserController.authorizeUser named the account and reported how long the lock had left to run, so an unauthenticated caller could tell a valid username from an invalid one. Both sites now return the generic "Incorrect username or password." unless password.allowdetailedautherrors is set, which defaults to false. Fixing the messages alone left a much louder signal. The password digest is only reached for a valid, unlocked user: an unknown username never enters the block and a locked account returns before it, so both skipped 600k PBKDF2 iterations. Measured on this branch with generic messages enabled, n=250 per cohort, the medians were 230.90 ms for a valid unlocked user against 0.49 ms locked and 0.76 ms unknown, with no overlap between the distributions. One timed request classified any username, whatever the response said. Both miss paths now run a throwaway digest and discard the result. The hash is generated once from the live digester so it follows the configured algorithm and iteration count. Re-measured the same way, the three cohorts land at 236.84, 237.74 and 237.05 ms, within a millisecond of each other and inside the run-to-run noise. Every path that skips the real digest now equalizes: an unknown username, a locked account, an account that exists but has never had a password set, and a failed pre-2.2 hash check. The last two are reachable in normal use, since an admin can create an account before provisioning it and legacy hashes survive upgrades. This still does not make the paths identical. A valid user costs one extra credentials query that a miss does not. That measured under a millisecond against a 237 ms floor, so the practical oracle is closed rather than the timing being constant. Locked accounts are no longer cheap to reject, which gives up a property lockout previously provided. An attacker gains nothing by locking an account, since every username now costs the same, but the tradeoff is deliberate rather than overlooked. Adopts the approach from OpenIntegrationEngine#241 by Mitch Gaffigan, reworked for the current source layout. That branch predates the move to src/main/java and no longer applies. Takes the allowDetailedAuthErrors name Tony Germano suggested on that thread, and sets the field explicitly in both constructors as he asked, rather than widening the eleven-argument constructor and churning its callers. DefaultUserControllerAuthorizeUserTest covers the message behaviour: a locked account is generic by default, reports detail when the property is set, and is indistinguishable from an unknown username. The first and third fail without this change. It binds mock ControllerFactory and SqlConfig through Guice static injection, the way DefaultHttpConfigurationTest already does, so no database is involved. Nothing asserts the timing, which is measured against a running server. Also renames PasswordRequirementsTests to PasswordRequirementsTest. The build includes '**/*Test.class' only, so the plural-named class had never been run; the rename puts its existing cases into the suite along with the two new ones. Closes OpenIntegrationEngine#240 Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
mgaffigan
left a comment
There was a problem hiding this comment.
I think this is unlikely to prevent timing side channel analysis, but it's better than nothing.
Far from perfect. Not really sure how to best do this by piling on more stuff, especially given the variation in machines and configs. Maybe change authroizeUser a lot or look into rate limiting, etc. At some threshold the behavior does have a pattern. And a single user name look up make the variation after a number of calls easier to see. |
Agreed. When a secure implementation is required, my answer remains "Don't try and do user auth; it's deceptively hard. Use an a separate OIDC IdP." |
tonygermano
left a comment
There was a problem hiding this comment.
My issues before were
- The system does not log an event to inform an admin that a user has been locked out, and all login failures look the same.
- For example, if there are 5 attempts allowed, the 5th failure should produce a server event separate from the normal login failure indicating the user has been locked.
- At a minimum, the 6th login failure should contain a reason that the account was locked if the user used the correct credentials.
- The user panel and user edit screens do not indicate if a user is locked
If those things were taken care of, then I don't think we even need a mirth.properties option to keep the old messages; just change them to a generic authentication error message (i.e., do not say "invalid username or password", because that might not be true). Changing the messages without fixing at least some of the above I think creates an unmanageable environment for something that is really low risk in the first place.
Not a blocker for this PR, but should definitely be addressed:
- There is no way over the REST API (and, therefore, from the admin client) for an admin to unlock a user once locked. These should not be linked, but changing the locked user's password does not even work as a workaround, because it doesn't reset the fields related to the lockout.
|
@tonygermano Would this clear it?
The property stays for now. Lock status in the user panel and edit screens, plus a REST unlock, would be a separate issue and PR. Disabling a user, which doesn't exist at all today, would be its own issue too. |
@pacmano1 I think just getting the server events worked out would be enough to drop the property and allow this to merge "always on." The second suggestion you made should not be implemented, because it leaks other information similar to what this PR is trying to prevent. It allows an attacker to brute force passwords for a suspected account while the account is locked and be informed when they have hit the correct one. They would only need to wait for the unlock to happen and then sign in. I don't know how difficult this would be to implement with the way things are currently set up, but it would be nice if a login failure event always had a failure reason (invalid credentials, account locked, etc...) |
Every failed login now returns FAIL and "Login failed.", and there is no property to bring the detailed messages back. The message no longer says "Incorrect username or password.", which isn't true for a locked account given the right password. The failed login that locks an account dispatches a "User account locked" server event (WARNING, FAILURE) with the username and the time it unlocks, so an admin can still see the lock. It is dispatched from DefaultUserController, so HTTP basic auth on the API gets it too. Requested by tonygermano in review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
|
@tonygermano Done: the property is gone, every failed login says Co-authored with Claude Code. |
|
I don't love the equalizeAuthenticationTime - can we use the normal "branchless" model? Do all the same work in all cases and compare once at the end? |
Rather than running a throwaway digest on each path that skipped the real one, every attempt now does one credentials query (an unknown username looks up an id no user has) and one full digest, against a throwaway hash when there is no usable stored one, and the outcome is decided once at the end. A pre-2.2 hash keeps its own cheap check alongside the digest. Requested by mgaffigan in review. This also covers the credentials query a valid user used to cost and an unknown one didn't, so the ponytail note about it goes, along with the double-checked locking and the logged-once warning around the old helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
A locked account given its correct password must still fail, with its strikes neither reset nor added to. No test covered it: every case used a wrong password, so dropping the lock from the final check still passed the whole class. Adds that case, and a plain successful login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
|
Lotta effort here for arguably low value. Can't drive the car if I can't get to the road. Obligatory Car Analogy |
100%. My position remains "remove user/pass auth and use OIDC". Defending it is a losing game. |
tonygermano
left a comment
There was a problem hiding this comment.
Thanks for working through all of this. The lockout event and the generic message cover what I asked for in my earlier review. With lock status and unlock tracked in #529 and failure reasons in #528, I'm good with this going in "always on" without the property.
Three things I'd like done before merge:
1. Update the PR description to match the final code. Parts of it, such as the ponytail: marker and the extra credentials query, describe the approach from before 75d834f. It should also name the timing difference that is left. A wrong password for a real, unlocked account calls incrementStrikes(), which does a DB update and a read, and resetExpiredStrikes() does the same when old strikes have expired. An unknown username does neither. That's small next to the digest, but probably larger than the query difference the description mentions now. I don't think it can be closed without keeping strike rows for usernames that don't exist, so stating it is enough.
2. Squash into a single commit, with a commit message that describes the final version.
3. Add release note text to the PR description, clearly labeled as the release note. This changes what users see when a login fails and how admins find out about locked accounts. The "Behavior change" section is a good starting point.
Guice / #519: The new test uses Guice static injection, which #519 removes. Whichever merges second will need to update this test.
Claude checked that nothing else references FAIL_LOCKED_OUT (keeping the enum value is right, since plugins can still return it), that the pre-2.2 path still upgrades the hash on success, and that a locked account with the correct password fails without its strikes being reset or added to. Also good catch on the PasswordRequirementsTests rename: that class never ran.
Approving on the understanding that these get done before merge.
🤖 Drafted with Claude Code
Fixes #240.
A failed login gave away whether the username was real, in two separate ways. This fixes both, and adds a server event so an admin can still see when an account locks.
The messages
The lockout paths in
DefaultUserController.authorizeUsernamed the account and reported how long the lock had left to run, and a wrong password for a real account reported the attempts left before the lock. An unauthenticated caller could tell a valid username from an invalid one. A wrong password, an unknown username and a locked account now all returnFAILandLogin failed.Per @tonygermano, the message doesn't say "incorrect username or password", because for a locked account given the right password that isn't true. There is no property to bring the detailed messages back.
The lockout event
Per @tonygermano, the failed login that locks an account dispatches a
User account lockedserver event, level WARNING, outcome FAILURE, with theusernameand the time it unlocks (locked until). Attempts against a locked account are rejected before a strike is counted, so there is one event per lock.It is dispatched from
DefaultUserController, so it covers HTTP basic auth on the API too, which goes throughMirthServletand logs no Login event at all. It carries no IP address, since the controller doesn't have one; the Login event logged for the same/users/_loginrequest has it.On a running engine with
retrylimit = 30, the 31st wrong password logged oneUser account lockedevent withusernameand alocked untilan hour later. The 32 attempts after it against the locked account, the right password included, logged no more. Basic auth onGET /api/users/currentdid the same, and there the lock event was the only event the 33 failed requests left.The timing
Fixing the messages alone left a second and much larger signal in place. Closing #240 on the message fix by itself would have overstated what changed.
The password digest is only reached for a valid, unlocked user. An unknown username never enters the
validUser != nullblock, and a locked account returns before it, so both skipped 600,000 PBKDF2 iterations. Measured with generic messages already enabled, 250 samples per cohort:The distributions do not overlap. The fastest unlocked sample was still 240x slower than the slowest locked one, so one timed request classified any username whatever the response body said. An attacker could enumerate accounts without reading a message at all.
A same-account control confirms the cause. An account that crossed the strike limit partway through a run stepped from 232.03 ms to 0.33 ms at the boundary, with no samples in between. The account did not change, only its lockout state did.
Every path that skips the real digest now runs a throwaway one and discards the result: an unknown username, a locked account, an account that exists but has never had a password set, and a failed pre-2.2 hash check. The last two are reachable in normal use, since an admin can create an account before provisioning it and legacy hashes survive upgrades. The throwaway goes through
matches()so the fallback digester runs the way it does on the real path, and the hash is generated once from the live digester so it follows the configured algorithm and iteration count. Measured again on the same rig by the same method:The no-password case was checked separately against a real account and came back at 234.16 ms against 233.89 for a valid user and 233.45 for an unknown one.
Rechecked on this commit on a slower machine, where one digest costs about 1.25 s, with 30 interleaved samples each: unknown username 1237.75 ms, valid + unlocked 1251.88 ms, valid + locked 1250.89 ms.
This is not constant time and I am not claiming it is. A valid user still costs one extra credentials query that a miss does not, which measured under a millisecond against a 237 ms floor. There is a
ponytail:marker on the helper naming that ceiling and the condition for revisiting it.There is a tradeoff here worth arguing with. Locked accounts are no longer cheap to reject, so lockout no longer caps the cost of repeatedly probing an account already known to exist. My reasoning is that an attacker gains nothing by locking an account once every username costs the same, so that cheapness was mostly helping the attacker. Tell me if you see it differently.
What this does not cover
An
AuthorizationPluginreturning a non-nullLoginStatusreturns before any of this code, so OIDC, LDAP and similar plugins are unaffected in both directions: nothing here breaks them, and nothing here protects them. Their messages and timing are their own. A plugin that replacesUserControlleroutright throughExtensionLoaderwould miss this entirely, and grepping this repo will not reveal one.Left for their own issues: a failure reason on every failed Login event (the reason is known in the controller, but the Login event is built in the servlet from the same
LoginStatusthe caller receives), and lock status in the user screens plus a REST unlock.Tests
DefaultUserControllerAuthorizeUserTestcovers what a failed login returns and the event it dispatches: a locked account gets the generic failure and is indistinguishable from an unknown username, a wrong password for a real account is indistinguishable from an unknown username, and the attempt that locks an account dispatches oneUser account lockedevent while the attempts after it dispatch none. All four fail against current main and pass with this change.It binds mock
ControllerFactoryandSqlConfigthrough Guice static injection, the wayDefaultHttpConfigurationTestalready does, so no database is involved. Lockout state is set directly on theUser, becauseLoginRequirementsCheckerreads the strike count and last strike time straight off it.No test asserts the timing. Mocking the digester removes the cost being measured, and a real one makes every call take 230 ms and turns the suite into a flaky performance test. The numbers above come from a running server.
Not added: a smoke test in
ci/. The new harness boots a real engine across seven configurations and has a Java seam with a liveClient, so the message behaviour could be asserted there against all five databases. That is worth doing and I have not done it; it belongs in its own change rather than growing this one further. The timing assertion would still be a poor fit for shared runners.Clean build, 685 tests, 0 failures.
Credit
This is @mgaffigan's fix from #241, reworked for the current source layout. That branch predates the June move to
src/main/javaand no longer applies. The timing half and the lockout event are new.Test rename
PasswordRequirementsTestsbecomesPasswordRequirementsTest. The build includes**/*Test.classonly, so the plural-named class has never run in any build. Six dormant cases now execute and pass. The dead*Testsproblem is wider than this file and needs its own issue.Behavior change
Every failed login now says
Login failed., where it used to sayIncorrect username or password.and, with lockout enabled, add the attempts left or the lock and its remaining time. Anything scraping the message string will notice, and a locked-out user is no longer told they are locked; the admin sees the event instead. Raised on #241 by me and @ChristopherSchultz.How to verify by hand
Defaults are
retrylimit = 100andlockoutperiod = 1. That period is in hours rather than minutes, perDuration.standardHoursatLoginRequirementsChecker:99.POST /api/users/_loginwith a real username and a wrong password, 101 times. The 101st locks the accountFAIL,Login failed.GET /api/events?name=User%20account%20lockedshows one event for the account, withusernameandlocked untilCo-authored with Claude Code.